들어가며

함수형 프로그래밍은 이름만 들으면 꽤 학술적인 이야기처럼 들립니다. 순수함수, 부수효과, 불변성 같은 단어들이 먼저 튀어나오다 보니 “그래서 내 코드에 어떻게 적용하라는 거지?”에서 막히는 경우가 많습니다.

이번 글에서는 함수형 프로그래밍의 사전적 정의가 왜 실무에서 반쪽짜리인지, 그리고 그 대안으로 코드를 액션 / 계산 / 데이터로 나누는 방법을 정리해봅니다.


사전적 정의부터 시작해보면

함수형 프로그래밍을 검색하면 대체로 이런 설명이 나옵니다.

부수효과(side effect)를 피하고 순수함수(pure function)만으로 프로그램을 구성하는 방식

여기서 두 단어의 뜻을 먼저 짚고 가겠습니다.

부수효과

함수가 리턴값을 계산해서 돌려주는 것 이외의 모든 일입니다. 결제 요청, 로그인 처리, DOM 조작, 콘솔 출력, 전역 변수 변경 전부 부수효과입니다.

순수함수

같은 인자를 넣으면 항상 같은 값이 나오는 함수입니다. 인자 외의 값에 의존하지 않고, 함수 바깥에 아무 영향도 주지 않습니다.

// 순수함수: 언제 몇 번을 호출하든 결과가 같습니다
function add(a, b) {
  return a + b
}

// 순수하지 않은 함수: 바깥의 count에 의존하고, 바깥의 count를 바꿉니다
let count = 0
function increase() {
  count += 1
  return count
}

그런데 이 정의에는 문제가 있습니다

정의대로라면 좋은 프로그램은 부수효과가 없어야 합니다. 하지만 부수효과가 없는 프로그램은 아무 쓸모가 없습니다.

결제도 부수효과고, 로그인도 부수효과고, 화면에 무언가를 그리는 것도 부수효과입니다. 우리가 만드는 서비스에서 사용자에게 가치를 주는 지점은 거의 전부 부수효과 쪽에 있습니다. 부수효과를 다 걷어내면 아무것도 하지 않고 조용히 종료되는 프로그램만 남습니다.

그래서 실용적인 함수형 프로그래밍은 방향을 바꿉니다.

함수형 프로그래밍은 부수효과를 없애는 것이 아니라, 부수효과를 잘 다루는 것입니다.

부수효과는 반드시 필요합니다. 다만 그것이 코드 전체에 아무렇게나 흩어져 있으면 곤란합니다. 어디에 있는지 알고, 필요한 만큼만 두고, 나머지는 안전한 영역으로 밀어내는 것이 핵심입니다.


코드를 세 가지로 나눠봅시다

실용적인 함수형 프로그래밍은 모든 코드를 다음 세 가지로 분류합니다.

분류 정의 특징
액션 호출하는 시점과 횟수에 의존하는 코드 부수효과가 있음. 호출할 때 조심해야 함
계산 입력으로 출력을 만드는 코드 순수함수. 언제 몇 번 실행해도 안전함
데이터 이벤트에 대해 기록한 사실 정적이며, 선언 위치나 시점에 의존하지 않음

액션

호출 시점과 호출 횟수에 결과가 달라지는 코드입니다. 이메일 전송, API 호출, 전역 상태 변경 같은 것들이 여기 속합니다.

이메일을 두 번 보내면 사용자는 메일을 두 통 받습니다. 즉 액션은 “실행했다”는 사실 자체가 결과의 일부이기 때문에, 호출할 때마다 조심해야 합니다.

계산

같은 입력이면 항상 같은 출력을 내는 코드입니다. 순수함수와 같은 말입니다.

계산은 실행하기 전까지는 결과를 알 수 없지만, 몇 번을 실행하든, 언제 실행하든 세상에 아무 흔적을 남기지 않습니다. 그래서 테스트하기 쉽고, 캐싱할 수 있고, 순서를 바꿔도 안전합니다.

데이터

이벤트에 대해 기록한 사실입니다. 정적이고, 선언되는 위치나 시점에 크게 의존하지 않습니다.

데이터 자체는 아무 일도 하지 않지만, 추론하고 토론하고 계산하기 위한 기초 정보가 됩니다.


액션과 계산은 어떻게 구분할까

실제로 코드를 열어보면 함수 대부분이 액션이고, 계산은 생각보다 적습니다. 심지어 액션이 액션을 호출하는 형태가 가장 흔합니다.

하지만 액션을 자세히 들여다보면 이야기가 달라집니다. 하나의 액션처럼 보이던 함수도 쪼개보면 데이터를 가져오는 부분, 그 데이터로 무언가를 판단하는 부분, 판단 결과를 세상에 반영하는 부분이 뒤섞여 있는 경우가 대부분입니다. 그 중간에 있는 “판단하는 부분”이 바로 계산입니다.

그래서 액션과 계산의 경계가 잘 보이지 않는다면, 일단 할 수 있는 만큼 잘게 쪼개보는 것이 가장 좋습니다. 쪼개다 보면 계산이 드러납니다.


쿠폰 추천 기능으로 확인해보기

“구독자에게 등급에 맞는 쿠폰 메일을 보낸다”는 기능을 만든다고 해보겠습니다. 각 단계가 어디에 속하는지 하나씩 붙여보겠습니다.

1. 구독자 목록 가져오기 → 액션

// 액션: DB의 현재 상태에 따라 결과가 달라집니다
async function fetchUsers() {
  const users = await db.query('SELECT * FROM users WHERE subscribed = true')
  return users // 반환된 목록 자체는 데이터입니다
}

같은 코드를 오늘 실행한 것과 내일 실행한 것의 결과가 다릅니다. 호출 시점에 의존하므로 액션입니다. 다만 돌려받은 목록 자체는 데이터입니다.

2. 쿠폰 목록 가져오기 → 액션

// 액션: 위와 같은 이유로 액션입니다
async function fetchCoupons() {
  const coupons = await db.query('SELECT * FROM coupons')
  return coupons
}

3. 보낼 이메일 계획하기 → 계산

// 계산: user와 coupons만 보고 결과가 정해집니다
function planEmailForUser(user, coupons) {
  const rank = user.recommendCount > 10 ? 'best' : 'good'
  const coupon = coupons.find(coupon => coupon.rank === rank)

  return {
    to: user.email,
    subject: '이번 주 쿠폰이 도착했습니다',
    body: `쿠폰 코드는 ${coupon.code} 입니다.`,
  }
}

이 함수는 메일을 보내지 않습니다. “이런 메일을 보내면 된다”는 계획을 만들어서 돌려줄 뿐입니다. 밖에서 아무것도 읽지 않고 아무것도 바꾸지 않으므로 계산입니다.

비즈니스 로직에서 가장 중요한 판단(누가 좋은 쿠폰을 받을지)이 바로 이 계산 안에 들어 있다는 점이 중요합니다.

4. 이메일 보내기 → 액션

// 액션: 두 번 호출하면 메일이 두 번 갑니다
async function sendEmails(emails) {
  for (const email of emails) {
    await emailService.send(email)
  }
}

5. 합쳐보면

async function sendCouponEmails() {
  // 전체는 액션
  const users = await fetchUsers() // 액션 → 결과는 데이터
  const coupons = await fetchCoupons() // 액션 → 결과는 데이터

  const emails = users.map(user => planEmailForUser(user, coupons)) // 계산

  await sendEmails(emails) // 액션
}

전체를 감싸는 sendCouponEmails는 액션입니다. 액션을 하나라도 포함하면 그 함수는 액션이 됩니다. 이건 함수형 프로그래밍에서 꽤 중요한 성질인데, 액션은 위로 전염되기 때문에 어디까지 액션을 끌어올릴지가 설계의 핵심이 됩니다.

여기서 눈여겨볼 것은 구조입니다.

  • 위쪽: 데이터를 읽어오는 액션
  • 가운데: 데이터로 판단하는 계산
  • 아래쪽: 결과를 반영하는 액션

액션이 계산을 위아래로 감싸는 샌드위치 형태가 됩니다. 이렇게 되면 가장 복잡하고 자주 바뀌는 로직(planEmailForUser)이 순수함수 안에 격리되어, DB나 메일 서버 없이도 단위 테스트를 작성할 수 있습니다.

참고: 지역 변수를 바꾸는 건 액션일까?

원래 이 코드를 이렇게 쓸 수도 있습니다.

let emails = []

for (const user of users) {
  emails.push(planEmailForUser(user, coupons)) // 배열을 변경하고 있습니다
}

push는 배열을 변경하니 부수효과처럼 보입니다. 하지만 이 emails함수 안에서 만들어졌고 함수 밖에서는 관찰할 수 없는 지역 변수입니다. 함수 바깥에서 보면 아무 흔적이 없으므로 이 정도는 계산으로 취급해도 괜찮습니다.

문제가 되는 것은 인자로 받은 배열을 바꾸거나, 모듈 스코프의 변수를 바꾸는 경우입니다. 그때부터는 호출 순서와 횟수가 결과를 바꾸기 시작합니다.


그렇다면 시그널 업데이트는 액션일까, 계산일까

앵귤러의 시그널을 예로 들어보겠습니다.

count.set(count() + 1)

액션입니다. 시그널 객체의 값을 변경한다는 부수효과가 명확하기 때문입니다. 두 번 호출하면 값이 2 올라가고, 이 시그널을 구독하는 모든 곳이 다시 계산됩니다. 호출 시점과 횟수에 결과가 달라지므로 액션의 정의에 정확히 들어맞습니다.

반대로 computed는 계산에 가깝습니다.

const doubled = computed(() => count() * 2)

count가 같으면 doubled는 항상 같은 값입니다. 몇 번을 읽어도 결과가 달라지지 않고, 읽는 것만으로는 어떤 흔적도 남지 않습니다.

여기서 프레임워크가 어떤 설계를 했는지가 보입니다. 상태를 바꾸는 통로는 set, update 같은 명시적인 액션으로 한정해두고, 그 값에서 파생되는 것들은 전부 계산으로 표현하게 만들었습니다. 액션이 들어올 수 있는 입구를 좁게 만든 셈입니다.


정리

이번 글에서는 함수형 프로그래밍의 개요와, 코드를 액션·계산·데이터로 구분하는 방법을 정리했습니다.

  • 사전적 정의(부수효과 없음)만 따라가면 실무에서는 쓸 수 있는 코드가 남지 않습니다.
  • 실용적인 함수형 프로그래밍의 목표는 부수효과를 없애는 것이 아니라 잘 다루는 것입니다.
  • 그 첫걸음이 코드를 액션 / 계산 / 데이터로 분류하는 것입니다.

우리가 짜는 코드는 대부분 액션이고, 액션이 액션을 호출하는 형태가 가장 빈번합니다. 하지만 비즈니스 로직의 본질은 대부분 계산에 가깝습니다. 액션 안에 섞여 있어서 잘 보이지 않을 뿐입니다.

이 둘을 구분해두면 테스트하기 쉬워지고, 코드를 읽을 때 “이건 지금 실행해도 안전한가?”를 매번 고민하지 않아도 됩니다. 흐름을 눈으로 따라가며 추론할 수 있게 되는 것, 그게 이 구분이 주는 가장 큰 이득이라고 생각합니다.

다음 글에서는 액션에서 계산을 실제로 어떻게 빼내는지 정리해보겠습니다.